// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.download;

import static android.app.DownloadManager.ACTION_NOTIFICATION_CLICKED;

import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.DownloadManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.shapes.OvalShape;
import android.net.Uri;
import android.os.Binder;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.service.notification.StatusBarNotification;
import android.support.v4.app.NotificationCompat;
import android.text.TextUtils;
import android.util.Pair;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.StrictModeContext;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.library_loader.LibraryProcessType;
import org.chromium.base.library_loader.ProcessInitException;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.AppHooks;
import org.chromium.chrome.browser.ChromeApplication;
import org.chromium.chrome.browser.download.items.OfflineContentAggregatorNotificationBridgeUiFactory;
import org.chromium.chrome.browser.init.BrowserParts;
import org.chromium.chrome.browser.init.ChromeBrowserInitializer;
import org.chromium.chrome.browser.init.EmptyBrowserParts;
import org.chromium.chrome.browser.media.MediaViewerUtils;
import org.chromium.chrome.browser.notifications.ChromeNotificationBuilder;
import org.chromium.chrome.browser.notifications.NotificationBuilderFactory;
import org.chromium.chrome.browser.notifications.NotificationConstants;
import org.chromium.chrome.browser.notifications.NotificationUmaTracker;
import org.chromium.chrome.browser.notifications.channels.ChannelDefinitions;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.util.IntentUtils;
import org.chromium.components.offline_items_collection.ContentId;
import org.chromium.components.offline_items_collection.LegacyHelpers;
import org.chromium.components.offline_items_collection.OfflineItem.Progress;
import org.chromium.content_public.browser.BrowserStartupController;

import java.util.ArrayList;
import java.util.List;

/**
 * Service responsible for creating and updating download notifications even after
 * Chrome gets killed.
 *
 * On O and above, this service will receive {@link Service#startForeground(int, Notification)}
 * calls when containing active downloads.  The foreground notification will be the summary
 * notification generated by {@link DownloadNotificationService#buildSummaryNotification(Context)}.
 * The service will receive a {@link Service#stopForeground(boolean)} call when all active downloads
 * are paused.  The summary notification will be hidden when there are no other notifications in the
 * {@link NotificationConstants#GROUP_DOWNLOADS} group.  This gets checked after every notification
 * gets removed from the {@link NotificationManager}.
 */
public class DownloadNotificationService extends Service {
    static final String EXTRA_DOWNLOAD_CONTENTID_ID =
            "org.chromium.chrome.browser.download.DownloadContentId_Id";
    static final String EXTRA_DOWNLOAD_CONTENTID_NAMESPACE =
            "org.chromium.chrome.browser.download.DownloadContentId_Namespace";
    static final String EXTRA_DOWNLOAD_FILE_PATH = "DownloadFilePath";
    static final String EXTRA_NOTIFICATION_DISMISSED = "NotificationDismissed";
    static final String EXTRA_IS_SUPPORTED_MIME_TYPE = "IsSupportedMimeType";
    static final String EXTRA_IS_OFF_THE_RECORD =
            "org.chromium.chrome.browser.download.IS_OFF_THE_RECORD";

    public static final String ACTION_DOWNLOAD_CANCEL =
            "org.chromium.chrome.browser.download.DOWNLOAD_CANCEL";
    public static final String ACTION_DOWNLOAD_PAUSE =
            "org.chromium.chrome.browser.download.DOWNLOAD_PAUSE";
    public static final String ACTION_DOWNLOAD_RESUME =
            "org.chromium.chrome.browser.download.DOWNLOAD_RESUME";
    static final String ACTION_DOWNLOAD_RESUME_ALL =
            "org.chromium.chrome.browser.download.DOWNLOAD_RESUME_ALL";
    public static final String ACTION_DOWNLOAD_OPEN =
            "org.chromium.chrome.browser.download.DOWNLOAD_OPEN";
    public static final String ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON =
            "org.chromium.chrome.browser.download.DOWNLOAD_UPDATE_SUMMARY_ICON";
    public static final String ACTION_DOWNLOAD_FAIL_SAFE =
            "org.chromium.chrome.browser.download.ACTION_SUMMARY_FAIL_SAFE";

    static final String NOTIFICATION_NAMESPACE = "DownloadNotificationService";
    private static final String TAG = "DownloadNotification";
    // Limit file name to 25 characters. TODO(qinmin): use different limit for different devices?
    private static final int MAX_FILE_NAME_LENGTH = 25;

    /** Notification Id starting value, to avoid conflicts from IDs used in prior versions. */

    private static final String EXTRA_NOTIFICATION_BUNDLE_ICON_ID =
            "Chrome.NotificationBundleIconIdExtra";
    private static final int STARTING_NOTIFICATION_ID = 1000000;
    private static final int MAX_RESUMPTION_ATTEMPT_LEFT = 5;

    private static final String KEY_AUTO_RESUMPTION_ATTEMPT_LEFT = "ResumptionAttemptLeft";
    private static final String KEY_NEXT_DOWNLOAD_NOTIFICATION_ID = "NextDownloadNotificationId";

    /**
     * An Observer interface that allows other classes to know when this class is canceling
     * downloads.
     */
    public interface Observer {
        /**
         * Called when a download was canceled from the notification.  The implementer is not
         * responsible for canceling the actual download (that should be triggered internally from
         * this class).  The implementer is responsible for using this to do their own tracking
         * related to which downloads might be active in this service.  File downloads don't trigger
         * a cancel event when they are told to cancel downloads, so classes might have no idea that
         * a download stopped otherwise.
         * @param id The {@link ContentId} of the download that was canceled.
         */
        void onDownloadCanceled(ContentId id);
    }

    private final ObserverList<Observer> mObservers = new ObserverList<>();
    private final IBinder mBinder = new LocalBinder();
    private final List<ContentId> mDownloadsInProgress = new ArrayList<ContentId>();

    private NotificationManager mNotificationManager;
    private SharedPreferences mSharedPrefs;
    private int mNextNotificationId;
    private int mNumAutoResumptionAttemptLeft;
    private Bitmap mDownloadSuccessLargeIcon;
    private DownloadSharedPreferenceHelper mDownloadSharedPreferenceHelper;

    /**
     * @return Whether or not this service should be made a foreground service if there are active
     * downloads.
     */
    @VisibleForTesting
    static boolean useForegroundService() {
        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
    }

    /**
     * Start this service with a summary {@link Notification}.  This will start the service in the
     * foreground.
     * @param context The context used to build the notification and to start the service.
     * @param source The {@link Intent} that should be used to build on to start the service.
     */
    public static void startDownloadNotificationService(Context context, Intent source) {
        Intent intent = source != null ? new Intent(source) : new Intent();
        intent.setComponent(new ComponentName(context, DownloadNotificationService.class));

        if (useForegroundService()) {
            NotificationManager manager =
                    (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
            // Attempt to update the notification summary icon without starting the service.
            if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) {
                // updateSummaryIcon should be a noop if the notification isn't showing or if the
                // icon won't change anyway.
                updateSummaryIcon(context, manager, -1, null);
                return;
            }

            AppHooks.get().startForegroundService(intent);
        } else {
            context.startService(intent);
        }
    }

    /**
     * Updates the notification summary with a new icon, if necessary.
     * @param removedNotificationId The id of a notification that is currently closing and should be
     *                              ignored.  -1 if no notifications are being closed.
     * @param addedNotification     A {@link Pair} of <id, Notification> of a notification that is
     *                              currently being added and should be used in addition to or in
     *                              place of the existing icons.
     */
    private static void updateSummaryIcon(Context context, NotificationManager manager,
            int removedNotificationId, Pair<Integer, Notification> addedNotification) {
        if (!useForegroundService()) return;

        Pair<Boolean, Integer> icon =
                getSummaryIcon(context, manager, removedNotificationId, addedNotification);
        if (!icon.first || !hasDownloadNotifications(manager, removedNotificationId)) return;

        manager.notify(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY,
                buildSummaryNotificationWithIcon(context, icon.second));
    }

    /**
     * Returns whether or not there are any download notifications showing that aren't the summary
     * notification.
     * @param notificationIdToIgnore If not -1, the id of a notification to ignore and
     *                               assume is closing or about to be closed.
     * @return Whether or not there are valid download notifications currently visible.
     */
    @TargetApi(Build.VERSION_CODES.M)
    private static boolean hasDownloadNotifications(
            NotificationManager manager, int notificationIdToIgnore) {
        if (!useForegroundService()) return false;

        StatusBarNotification[] notifications = manager.getActiveNotifications();
        for (StatusBarNotification notification : notifications) {
            boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(),
                    NotificationConstants.GROUP_DOWNLOADS);
            boolean isSummaryNotification =
                    notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY;
            boolean isIgnoredNotification =
                    notificationIdToIgnore != -1 && notificationIdToIgnore == notification.getId();
            if (isDownloadsGroup && !isSummaryNotification && !isIgnoredNotification) return true;
        }

        return false;
    }

    /**
     * Calculates the suggested icon for the summary notification based on the other notifications
     * currently showing.
     * @param context A context to use to query Android-specific information (NotificationManager).
     * @param removedNotificationId The id of a notification that is currently closing and should be
     *                              ignored.  -1 if no notifications are being closed.
     * @param addedNotification     A {@link Pair} of <id, Notification> of a notification that is
     *                              currently being added and should be used in addition to or in
     *                              place of the existing icons.
     * @return                      A {@link Pair} that represents both whether or not the new icon
     *                              is different from the old one and the icon id itself.
     */
    @TargetApi(Build.VERSION_CODES.M)
    private static Pair<Boolean, Integer> getSummaryIcon(Context context,
            NotificationManager manager, int removedNotificationId,
            Pair<Integer, Notification> addedNotification) {
        if (!useForegroundService()) return new Pair<Boolean, Integer>(false, -1);
        boolean progress = false;
        boolean paused = false;
        boolean pending = false;
        boolean completed = false;
        boolean failed = false;

        final int progressIcon = android.R.drawable.stat_sys_download;
        final int pausedIcon = R.drawable.ic_download_pause;
        final int pendingIcon = R.drawable.ic_download_pending;
        final int completedIcon = R.drawable.offline_pin;
        final int failedIcon = android.R.drawable.stat_sys_download_done;

        StatusBarNotification[] notifications = manager.getActiveNotifications();

        int oldIcon = -1;
        for (StatusBarNotification notification : notifications) {
            boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(),
                    NotificationConstants.GROUP_DOWNLOADS);
            if (!isDownloadsGroup) continue;
            if (notification.getId() == removedNotificationId) continue;

            boolean isSummaryNotification =
                    notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY;

            if (addedNotification != null && addedNotification.first == notification.getId()) {
                continue;
            }

            int icon =
                    notification.getNotification().extras.getInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID);
            if (isSummaryNotification) {
                oldIcon = icon;
                continue;
            }

            progress |= icon == progressIcon;
            paused |= icon == pausedIcon;
            pending |= icon == pendingIcon;
            completed |= icon == completedIcon;
            failed |= icon == failedIcon;
        }

        if (addedNotification != null) {
            int icon = addedNotification.second.extras.getInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID);

            progress |= icon == progressIcon;
            paused |= icon == pausedIcon;
            pending |= icon == pendingIcon;
            completed |= icon == completedIcon;
            failed |= icon == failedIcon;
        }

        int newIcon = android.R.drawable.stat_sys_download_done;
        if (progress) {
            newIcon = android.R.drawable.stat_sys_download;
        } else if (pending) {
            newIcon = R.drawable.ic_download_pending;
        } else if (failed) {
            newIcon = android.R.drawable.stat_sys_download_done;
        } else if (paused) {
            newIcon = R.drawable.ic_download_pause;
        } else if (completed) {
            newIcon = R.drawable.offline_pin;
        }

        return new Pair<Boolean, Integer>(newIcon != oldIcon, newIcon);
    }

    /**
     * Builds a summary notification that represents all downloads.
     * {@see #buildSummaryNotification(Context)}.
     * @param context A context used to query Android strings and resources.
     * @param iconId  The id of an icon to use for the notification.
     * @return        a {@link Notification} that represents the summary icon for all downloads.
     */
    private static Notification buildSummaryNotificationWithIcon(Context context, int iconId) {
        ChromeNotificationBuilder builder =
                NotificationBuilderFactory
                        .createChromeNotificationBuilder(
                                true /* preferCompat */, ChannelDefinitions.ChannelId.DOWNLOADS)
                        .setContentTitle(
                                context.getString(R.string.download_notification_summary_title))
                        .setSubText(context.getString(R.string.menu_downloads))
                        .setSmallIcon(iconId)
                        .setLocalOnly(true)
                        .setGroup(NotificationConstants.GROUP_DOWNLOADS)
                        .setGroupSummary(true);
        Bundle extras = new Bundle();
        extras.putInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID, iconId);
        builder.addExtras(extras);

        // This notification should not actually be shown.  But if it is, set the click intent to
        // open downloads home.
        // TODO(dtrainor): Only do this if we have no transient downloads.
        Intent downloadHomeIntent =
                buildActionIntent(context, ACTION_NOTIFICATION_CLICKED, null, false);
        builder.setContentIntent(PendingIntent.getBroadcast(context,
                NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, downloadHomeIntent,
                PendingIntent.FLAG_UPDATE_CURRENT));

        return builder.build();
    }

    /**
     * Builds a summary notification that represents downloads.  This is the notification passed to
     * {@link #startForeground(int, Notification)}, which keeps this service in the foreground.
     * @param context The context used to build the notification and pull specific resources.
     * @return The {@link Notification} to show for the summary.  Meant to be used by
     *         {@link NotificationManager#notify(int, Notification)}.
     */
    private static Notification buildSummaryNotification(
            Context context, NotificationManager manager) {
        Pair<Boolean, Integer> icon = getSummaryIcon(context, manager, -1, null);
        return buildSummaryNotificationWithIcon(context, icon.second);
    }

    /**
     * @return Whether or not there are any current resumable downloads being tracked.  These
     *         tracked downloads may not currently be showing notifications.
     */
    public static boolean isTrackingResumableDownloads(Context context) {
        List<DownloadSharedPreferenceEntry> entries =
                DownloadSharedPreferenceHelper.getInstance().getEntries();
        for (DownloadSharedPreferenceEntry entry : entries) {
            if (canResumeDownload(context, entry)) return true;
        }
        return false;
    }

    /**
     * Class for clients to access.
     */
    public class LocalBinder extends Binder {
        DownloadNotificationService getService() {
            return DownloadNotificationService.this;
        }
    }

    @Override
    public void onTaskRemoved(Intent rootIntent) {
        // Record instance of task removed.
        DownloadNotificationUmaHelper.recordServiceStoppedHistogram(
                DownloadNotificationUmaHelper.ServiceStopped.TASK_REMOVED, false);

        super.onTaskRemoved(rootIntent);
        // If we've lost all Activities, cancel the off the record downloads and validate that we
        // should still be showing any download notifications at all.
        if (ApplicationStatus.isEveryActivityDestroyed()) {
            cancelOffTheRecordDownloads();
            hideSummaryNotificationIfNecessary(-1);
        }
    }

    @Override
    public void onLowMemory() {
        // Record instance of service with low memory.
        DownloadNotificationUmaHelper.recordServiceStoppedHistogram(
                DownloadNotificationUmaHelper.ServiceStopped.LOW_MEMORY, false /*withForeground*/);
        super.onLowMemory();
    }

    @Override
    public void onCreate() {
        mNotificationManager =
                (NotificationManager) ContextUtils.getApplicationContext().getSystemService(
                        Context.NOTIFICATION_SERVICE);
        mSharedPrefs = ContextUtils.getAppSharedPreferences();
        mNumAutoResumptionAttemptLeft =
                mSharedPrefs.getInt(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT, MAX_RESUMPTION_ATTEMPT_LEFT);
        mDownloadSharedPreferenceHelper = DownloadSharedPreferenceHelper.getInstance();
        mNextNotificationId =
                mSharedPrefs.getInt(KEY_NEXT_DOWNLOAD_NOTIFICATION_ID, STARTING_NOTIFICATION_ID);
    }

    @Override
    public void onDestroy() {
        // Record instance of service being destroyed.
        DownloadNotificationUmaHelper.recordServiceStoppedHistogram(
                DownloadNotificationUmaHelper.ServiceStopped.DESTROYED, false /* withForeground */);

        updateNotificationsForShutdown();
        rescheduleDownloads();
        super.onDestroy();
    }

    @Override
    public int onStartCommand(final Intent intent, int flags, int startId) {
        // Start a foreground service every time we process a valid intent.  This makes sure we
        // honor the promise that we'll be in the foreground when we start, even if we immediately
        // drop ourselves back.
        if (useForegroundService() && intent != null) startForegroundInternal();

        if (intent == null) {
            // Record instance of service restarting.
            DownloadNotificationUmaHelper.recordServiceStoppedHistogram(
                    DownloadNotificationUmaHelper.ServiceStopped.START_STICKY, false);

            // Intent is only null during a process restart because of returning START_STICKY.  In
            // this case cancel the off the record notifications and put the normal notifications
            // into a pending state, then try to restart.  Finally validate that we are actually
            // showing something.
            updateNotificationsForShutdown();
            handleDownloadOperation(
                    new Intent(DownloadNotificationService.ACTION_DOWNLOAD_RESUME_ALL));
            hideSummaryNotificationIfNecessary(-1);
        } else if (TextUtils.equals(intent.getAction(),
                           DownloadNotificationService.ACTION_DOWNLOAD_FAIL_SAFE)) {
            hideSummaryNotificationIfNecessary(-1);
        } else if (isDownloadOperationIntent(intent)) {
            handleDownloadOperation(intent);
            DownloadResumptionScheduler.getDownloadResumptionScheduler().cancel();
            // Limit the number of auto resumption attempts in case Chrome falls into a vicious
            // cycle.
            if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) {
                if (mNumAutoResumptionAttemptLeft > 0) {
                    mNumAutoResumptionAttemptLeft--;
                    updateResumptionAttemptLeft();
                }
            } else {
                // Reset number of attempts left if the action is triggered by user.
                mNumAutoResumptionAttemptLeft = MAX_RESUMPTION_ATTEMPT_LEFT;
                clearResumptionAttemptLeft();
            }
        }
        // This should restart the service after Chrome gets killed. However, this
        // doesn't work on Android 4.4.2.
        return START_STICKY;
    }

    /**
     * Adds an {@link Observer}, which will be notified when this service attempts to
     * start stopping itself.
     */
    public void addObserver(Observer observer) {
        mObservers.addObserver(observer);
    }

    /**
     * Removes {@code observer}, which will no longer be notified when this class decides to start
     * stopping itself.
     */
    public void removeObserver(Observer observer) {
        mObservers.removeObserver(observer);
    }

    /**
     * On >= O Android releases, puts this service into a background state.
     * @param killNotification Whether or not this call should kill the summary notification or not.
     *                         Not killing it puts the service into the background, but leaves the
     *                         download notifications visible.
     */
    @VisibleForTesting
    @TargetApi(Build.VERSION_CODES.N)
    void stopForegroundInternal(boolean killNotification) {
        Log.w(TAG, "stopForegroundInternal killNotification: " + killNotification);
        if (!useForegroundService()) return;
        stopForeground(killNotification ? STOP_FOREGROUND_REMOVE : STOP_FOREGROUND_DETACH);
    }

    /**
     * On >= O Android releases, puts this service into a foreground state, binding it to the
     * {@link Notification} generated by {@link #buildSummaryNotification(Context)}.
     */
    @VisibleForTesting
    void startForegroundInternal() {
        Log.w(TAG, "startForegroundInternal");
        if (!useForegroundService()) return;
        Notification notification = buildSummaryNotification(
                ContextUtils.getApplicationContext(), mNotificationManager);
        startForeground(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, notification);
    }

    @VisibleForTesting
    boolean hasDownloadNotificationsInternal(int notificationIdToIgnore) {
        return hasDownloadNotifications(mNotificationManager, notificationIdToIgnore);
    }

    private void rescheduleDownloads() {
        if (mNumAutoResumptionAttemptLeft <= 0) return;
        DownloadResumptionScheduler.getDownloadResumptionScheduler().scheduleIfNecessary();
    }

    @VisibleForTesting
    void updateNotificationsForShutdown() {
        cancelOffTheRecordDownloads();
        List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries();
        for (DownloadSharedPreferenceEntry entry : entries) {
            if (entry.isOffTheRecord) continue;
            // Move all regular downloads to pending.  Don't propagate the pause because
            // if native is still working and it triggers an update, then the service will be
            // restarted.
            notifyDownloadPaused(entry.id, entry.fileName, !entry.isOffTheRecord, true,
                    entry.isOffTheRecord, entry.isTransient, null);
        }
    }

    @VisibleForTesting
    void cancelOffTheRecordDownloads() {
        boolean cancelActualDownload =
                BrowserStartupController.get(LibraryProcessType.PROCESS_BROWSER)
                        .isStartupSuccessfullyCompleted()
                && Profile.getLastUsedProfile().hasOffTheRecordProfile();

        List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries();
        List<DownloadSharedPreferenceEntry> copies =
                new ArrayList<DownloadSharedPreferenceEntry>(entries);
        for (DownloadSharedPreferenceEntry entry : copies) {
            if (!entry.isOffTheRecord) continue;
            ContentId id = entry.id;
            notifyDownloadCanceled(id);
            if (cancelActualDownload) {
                DownloadServiceDelegate delegate = getServiceDelegate(id);
                delegate.cancelDownload(id, true);
                delegate.destroyServiceDelegate();
            }
            for (Observer observer : mObservers) observer.onDownloadCanceled(id);
        }
    }

    /**
     * Track in-progress downloads here and, if on an Android version >= O, make
     * this a foreground service.
     * @param id The {@link ContentId} of the download that has been started and should be tracked.
     */
    private void startTrackingInProgressDownload(ContentId id) {
        Log.w(TAG, "startTrackingInProgressDownload");
        if (mDownloadsInProgress.size() == 0) startForegroundInternal();
        if (!mDownloadsInProgress.contains(id)) mDownloadsInProgress.add(id);
    }

    /**
     * Stop tracking the download represented by {@code id}.  If on an Android version >= O, stop
     * making this a foreground service.
     * @param id                  The {@link ContentId} of the download that has been paused or
     *                            canceled and shouldn't be tracked.
     * @param allowStopForeground Whether or not this should check internal state and stop the
     *                            foreground notification from showing.  This could be false if we
     *                            plan on removing the notification in the near future.  We don't
     *                            want to just detach here, because that will put us in a
     *                            potentially bad state where we cannot dismiss the notification.
     */
    private void stopTrackingInProgressDownload(ContentId id, boolean allowStopForeground) {
        Log.w(TAG, "stopTrackingInProgressDownload");
        mDownloadsInProgress.remove(id);
        if (allowStopForeground && mDownloadsInProgress.size() == 0) stopForegroundInternal(false);
    }

    /**
     * @return The summary {@link StatusBarNotification} if one is showing.
     */
    @TargetApi(Build.VERSION_CODES.M)
    private static StatusBarNotification getSummaryNotification(NotificationManager manager) {
        if (!useForegroundService()) return null;

        StatusBarNotification[] notifications = manager.getActiveNotifications();
        for (StatusBarNotification notification : notifications) {
            boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(),
                    NotificationConstants.GROUP_DOWNLOADS);
            boolean isSummaryNotification =
                    notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY;
            if (isDownloadsGroup && isSummaryNotification) return notification;
        }

        return null;
    }

    /**
     * Cancels the existing summary notification.  Moved to a helper method for test mocking.
     */
    @VisibleForTesting
    void cancelSummaryNotification() {
        mNotificationManager.cancel(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY);
    }

    /**
     * Check all current notifications and hide the summary notification if we have no downloads
     * notifications left.  On Android if the user swipes away the last download notification the
     * summary will be dismissed.  But if the last downloads notification is dismissed via
     * {@link NotificationManager#cancel(int)}, the summary will remain, so we need to check and
     * manually remove it ourselves.
     * @param notificationIdToIgnore Canceling a notification and querying for the current list of
     *                               active notifications isn't synchronous.  Pass a notification id
     *                               here if there is a notification that should be assumed gone.
     *                               Or pass -1 if no notification fits that criteria.
     */
    @SuppressLint("NewApi") // useForegroundService guards StatusBarNotification.getNotification
    boolean hideSummaryNotificationIfNecessary(int notificationIdToIgnore) {
        Log.w(TAG, "hideSummaryNotificationIfNecessary id: " + notificationIdToIgnore);
        if (mDownloadsInProgress.size() > 0) return false;

        if (useForegroundService()) {
            if (hasDownloadNotificationsInternal(notificationIdToIgnore)) return false;

            StatusBarNotification notification = getSummaryNotification(mNotificationManager);
            if (notification != null) {
                // We have a valid summary notification, but how we dismiss it depends on whether or
                // not it is currently bound to this service via startForeground(...).
                if ((notification.getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE)
                        != 0) {
                    // If we are a foreground service and we are hiding the notification, we have no
                    // other downloads notifications showing, so we need to remove the notification
                    // and unregister it from this service at the same time.
                    stopForegroundInternal(true);
                } else {
                    // If we are not a foreground service, remove the notification via the
                    // NotificationManager.  The notification is not bound to this service, so any
                    // call to stopForeground() won't affect the notification.
                    cancelSummaryNotification();
                }
            } else {
                // If we don't have a valid summary, just guarantee that we aren't in the foreground
                // for safety.  Still try to remove the summary notification to make sure it's gone.
                // This is because querying for it might fail if we have just recently started up
                // and began showing it.  This might leave us in a bad state if the cancel request
                // fails inside the framework.
                // TODO(dtrainor): Add a way to attempt to automatically clean up the notification
                // shortly after this.
                stopForegroundInternal(true);
            }
        } else {
            // If we're not using a foreground service, just shut down after we are no longer
            // tracking any downloads.
            if (mDownloadSharedPreferenceHelper.getEntries().size() > 0) return false;
        }

        // Stop the service which should start the destruction process.  At this point we should be
        // a background service.  We might not be unbound from any clients.  When they unbind we
        // will shut down.  That is okay because they will only unbind from us when they are ok with
        // us going away (e.g. we shouldn't be unbound while in the foreground).
        stopSelf();

        // Record instance of service being stopped intentionally.
        DownloadNotificationUmaHelper.recordServiceStoppedHistogram(
                DownloadNotificationUmaHelper.ServiceStopped.STOPPED, false /* withForeground */);
        return true;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }

    /**
     * Helper method to update the remaining number of background resumption attempts left.
     */
    private void updateResumptionAttemptLeft() {
        SharedPreferences.Editor editor = mSharedPrefs.edit();
        editor.putInt(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT, mNumAutoResumptionAttemptLeft);
        editor.apply();
    }

    /**
     * Helper method to clear the remaining number of background resumption attempts left.
     */
    static void clearResumptionAttemptLeft() {
        SharedPreferences SharedPrefs = ContextUtils.getAppSharedPreferences();
        SharedPreferences.Editor editor = SharedPrefs.edit();
        editor.remove(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT);
        editor.apply();
    }

    /**
     * Adds or updates an in-progress download notification.
     * @param id                      The {@link ContentId} of the download.
     * @param fileName                File name of the download.
     * @param progress                The current download progress.
     * @param bytesReceived           Total number of bytes received.
     * @param timeRemainingInMillis   Remaining download time in milliseconds.
     * @param startTime               Time when download started.
     * @param isOffTheRecord          Whether the download is off the record.
     * @param canDownloadWhileMetered Whether the download can happen in metered network.
     * @param isTransient             Whether or not clicking on the download should launch
     *                                downloads home.
     * @param icon                    A {@link Bitmap} to be used as the large icon for display.
     */
    @VisibleForTesting
    public void notifyDownloadProgress(ContentId id, String fileName, Progress progress,
            long bytesReceived, long timeRemainingInMillis, long startTime, boolean isOffTheRecord,
            boolean canDownloadWhileMetered, boolean isTransient, Bitmap icon) {
        updateActiveDownloadNotification(id, fileName, progress, bytesReceived,
                timeRemainingInMillis, startTime, isOffTheRecord, canDownloadWhileMetered, false,
                isTransient, icon);
    }

    /**
     * Adds or updates a pending download notification.
     * @param id                      The {@link ContentId} of the download.
     * @param fileName                File name of the download.
     * @param isOffTheRecord          Whether the download is off the record.
     * @param canDownloadWhileMetered Whether the download can happen in metered network.
     * @param isTransient             Whether or not clicking on the download should launch
     *                                downloads home.
     * @param icon                    A {@link Bitmap} to be used as the large icon for display.
     */
    private void notifyDownloadPending(ContentId id, String fileName, boolean isOffTheRecord,
            boolean canDownloadWhileMetered, boolean isTransient, Bitmap icon) {
        updateActiveDownloadNotification(id, fileName, Progress.createIndeterminateProgress(), 0, 0,
                0, isOffTheRecord, canDownloadWhileMetered, true, isTransient, icon);
    }

    /**
     * Helper method to update the notification for an active download, the download is either in
     * progress or pending.
     * @param id                      The {@link ContentId} of the download.
     * @param fileName                File name of the download.
     * @param progress                The current download progress.
     * @param bytesReceived           Total number of bytes received.
     * @param timeRemainingInMillis   Remaining download time in milliseconds or -1 if it is
     *                                unknown.
     * @param startTime               Time when download started.
     * @param isOffTheRecord          Whether the download is off the record.
     * @param canDownloadWhileMetered Whether the download can happen in metered network.
     * @param isDownloadPending       Whether the download is pending.
     * @param isTransient             Whether or not clicking on the download should launch
     *                                downloads home.
     * @param icon                    A {@link Bitmap} to be used as the large icon for display.
     */
    private void updateActiveDownloadNotification(ContentId id, String fileName, Progress progress,
            long bytesReceived, long timeRemainingInMillis, long startTime, boolean isOffTheRecord,
            boolean canDownloadWhileMetered, boolean isDownloadPending, boolean isTransient,
            Bitmap icon) {
        boolean indeterminate = (progress.isIndeterminate() || isDownloadPending);
        String contentText = null;
        if (isDownloadPending) {
            contentText = ContextUtils.getApplicationContext().getResources().getString(
                    R.string.download_notification_pending);
        } else if (indeterminate || timeRemainingInMillis < 0) {
            // TODO(dimich): Enable the byte count back in M59. See bug 704049 for more info and
            // details of what was temporarily reverted (for M58).
            contentText = ContextUtils.getApplicationContext().getResources().getString(
                    R.string.download_started);
        } else {
            contentText = DownloadUtils.getTimeOrFilesLeftString(
                    ContextUtils.getApplicationContext(), progress, timeRemainingInMillis);
        }
        int resId = isDownloadPending ? R.drawable.ic_download_pending
                                      : android.R.drawable.stat_sys_download;
        ChromeNotificationBuilder builder = buildNotification(resId, fileName, contentText);
        builder.setOngoing(true);
        builder.setPriorityBeforeO(NotificationCompat.PRIORITY_HIGH);

        // Avoid animations while the download isn't progressing.
        if (!isDownloadPending) {
            builder.setProgress(100, indeterminate ? -1 : progress.getPercentage(), indeterminate);
        }

        if (!indeterminate && !LegacyHelpers.isLegacyOfflinePage(id)) {
            String percentText = DownloadUtils.getPercentageString(progress.getPercentage());
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                builder.setSubText(percentText);
            } else {
                builder.setContentInfo(percentText);
            }
        }
        int notificationId = getNotificationId(id);
        if (startTime > 0) builder.setWhen(startTime);

        if (!isTransient) {
            // Clicking on an in-progress download sends the user to see all their downloads.
            Intent downloadHomeIntent = buildActionIntent(ContextUtils.getApplicationContext(),
                    ACTION_NOTIFICATION_CLICKED, null, isOffTheRecord);
            builder.setContentIntent(
                    PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId,
                            downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT));
        }
        builder.setAutoCancel(false);
        if (icon != null) builder.setLargeIcon(icon);

        Intent pauseIntent = buildActionIntent(
                ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_PAUSE, id, isOffTheRecord);
        builder.addAction(R.drawable.ic_pause_white_24dp,
                ContextUtils.getApplicationContext().getResources().getString(
                        R.string.download_notification_pause_button),
                buildPendingIntent(pauseIntent, notificationId));

        Intent cancelIntent = buildActionIntent(
                ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord);
        builder.addAction(R.drawable.btn_close_white,
                ContextUtils.getApplicationContext().getResources().getString(
                        R.string.download_notification_cancel_button),
                buildPendingIntent(cancelIntent, notificationId));

        updateNotification(notificationId, builder.build(), id,
                new DownloadSharedPreferenceEntry(id, notificationId, isOffTheRecord,
                        canDownloadWhileMetered, fileName, true, isTransient));
        startTrackingInProgressDownload(id);
    }

    /**
     * Removes a download notification and all associated tracking.  This method relies on the
     * caller to provide the notification id, which is useful in the case where the internal
     * tracking doesn't exist (e.g. in the case of a successful download, where we show the download
     * completed notification and remove our internal state tracking).
     * @param notificationId Notification ID of the download
     * @param id The {@link ContentId} of the download.
     */
    public void cancelNotification(int notificationId, ContentId id) {
        mNotificationManager.cancel(NOTIFICATION_NAMESPACE, notificationId);
        mDownloadSharedPreferenceHelper.removeSharedPreferenceEntry(id);

        // Since we are about to go through the process of validating whether or not we can shut
        // down, don't stop foreground if we have no download notifications left to show.  Hiding
        // the summary will take care of that for us.
        stopTrackingInProgressDownload(id, hasDownloadNotificationsInternal(notificationId));
        if (!hideSummaryNotificationIfNecessary(notificationId)) {
            updateSummaryIcon(ContextUtils.getApplicationContext(), mNotificationManager,
                    notificationId, null);
        }
    }

    /**
     * Called when a download is canceled.  This method uses internal tracking to try to find the
     * notification id to cancel.
     * @param id The {@link ContentId} of the download.
     */
    @VisibleForTesting
    public void notifyDownloadCanceled(ContentId id) {
        DownloadSharedPreferenceEntry entry =
                mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id);
        if (entry == null) {
            // In case notifyDownloadCanceled was called after the entry has already been removed.
            stopTrackingInProgressDownload(id, hasDownloadNotificationsInternal(-1));
            return;
        }
        cancelNotification(entry.notificationId, id);
    }

    /**
     * Change a download notification to paused state.
     * @param id              The {@link ContentId} of the download.
     * @param fileName        File name of the download.
     * @param isResumable     Whether download can be resumed.
     * @param isAutoResumable Whether download is can be resumed automatically.
     * @param isOffTheRecord  Whether the download is off the record.
     * @param isTransient     Whether or not clicking on the download should launch downloads home.
     * @param icon            A {@link Bitmap} to be used as the large icon for display.
     */
    public void notifyDownloadPaused(ContentId id, String fileName, boolean isResumable,
            boolean isAutoResumable, boolean isOffTheRecord, boolean isTransient, Bitmap icon) {
        DownloadSharedPreferenceEntry entry =
                mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id);
        if (!isResumable) {
            notifyDownloadFailed(id, fileName, icon);
            return;
        }
        // Download is already paused.
        if (entry != null && !entry.isAutoResumable) {
            // Shutdown the service in case it was restarted unnecessarily.
            stopTrackingInProgressDownload(id, true);
            return;
        }
        boolean canDownloadWhileMetered = entry == null ? false : entry.canDownloadWhileMetered;
        // If download is interrupted due to network disconnection, show download pending state.
        if (isAutoResumable) {
            notifyDownloadPending(
                    id, fileName, isOffTheRecord, canDownloadWhileMetered, isTransient, icon);
            stopTrackingInProgressDownload(id, true);
            return;
        }

        String contentText = ContextUtils.getApplicationContext().getResources().getString(
                R.string.download_notification_paused);
        ChromeNotificationBuilder builder =
                buildNotification(R.drawable.ic_download_pause, fileName, contentText);
        int notificationId = entry == null ? getNotificationId(id) : entry.notificationId;
        if (!isTransient) {
            // Clicking on an in-progress download sends the user to see all their downloads.
            Intent downloadHomeIntent = buildActionIntent(
                    ContextUtils.getApplicationContext(), ACTION_NOTIFICATION_CLICKED, null, false);
            builder.setContentIntent(
                    PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId,
                            downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT));
        }
        builder.setAutoCancel(false);
        if (icon != null) builder.setLargeIcon(icon);

        Intent resumeIntent = buildActionIntent(
                ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_RESUME, id, isOffTheRecord);
        builder.addAction(R.drawable.ic_file_download_white_24dp,
                ContextUtils.getApplicationContext().getResources().getString(
                        R.string.download_notification_resume_button),
                buildPendingIntent(resumeIntent, notificationId));

        Intent cancelIntent = buildActionIntent(
                ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord);
        builder.addAction(R.drawable.btn_close_white,
                ContextUtils.getApplicationContext().getResources().getString(
                        R.string.download_notification_cancel_button),
                buildPendingIntent(cancelIntent, notificationId));
        PendingIntent deleteIntent = isTransient ? buildPendingIntent(cancelIntent, notificationId)
                                                 : buildSummaryIconIntent(notificationId);
        builder.setDeleteIntent(deleteIntent);

        updateNotification(notificationId, builder.build(), id,
                new DownloadSharedPreferenceEntry(id, notificationId, isOffTheRecord,
                        canDownloadWhileMetered, fileName, isAutoResumable, isTransient));
        stopTrackingInProgressDownload(id, true);
    }

    /**
     * Add a download successful notification.
     * @param id                  The {@link ContentId} of the download.
     * @param filePath            Full path to the download.
     * @param fileName            Filename of the download.
     * @param systemDownloadId    Download ID assigned by system DownloadManager.
     * @param isSupportedMimeType Whether the MIME type can be viewed inside browser.
     * @param isOpenable          Whether or not this download can be opened.
     * @param icon                A {@link Bitmap} to be used as the large icon for display.
     * @param originalUrl         The original url of the downloaded file.
     * @param referrer            Referrer of the downloaded file.
     * @return                    ID of the successful download notification. Used for removing the
     *                            notification when user click on the snackbar.
     */
    @VisibleForTesting
    public int notifyDownloadSuccessful(ContentId id, String filePath, String fileName,
            long systemDownloadId, boolean isOffTheRecord, boolean isSupportedMimeType,
            boolean isOpenable, Bitmap icon, String originalUrl, String referrer) {
        int notificationId = getNotificationId(id);
        ChromeNotificationBuilder builder = buildNotification(R.drawable.offline_pin, fileName,
                ContextUtils.getApplicationContext().getResources().getString(
                        R.string.download_notification_completed));
        ComponentName component =
                new ComponentName(ContextUtils.getApplicationContext().getPackageName(),
                        DownloadBroadcastReceiver.class.getName());

        if (isOpenable) {
            Intent intent = null;
            if (LegacyHelpers.isLegacyDownload(id)) {
                intent = new Intent(ACTION_NOTIFICATION_CLICKED);
                long[] idArray = {systemDownloadId};
                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, idArray);
                intent.putExtra(EXTRA_DOWNLOAD_FILE_PATH, filePath);
                intent.putExtra(EXTRA_IS_SUPPORTED_MIME_TYPE, isSupportedMimeType);
                intent.putExtra(EXTRA_IS_OFF_THE_RECORD, isOffTheRecord);
                intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_ID, id.id);
                intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE, id.namespace);
                intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_ID, notificationId);
                MediaViewerUtils.setOriginalUrlAndReferralExtraToIntent(
                        intent, originalUrl, referrer);
            } else {
                intent = buildActionIntent(
                        ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_OPEN, id, false);
            }

            intent.setComponent(component);
            builder.setContentIntent(
                    PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId,
                            intent, PendingIntent.FLAG_UPDATE_CURRENT));
        }
        if (icon == null && mDownloadSuccessLargeIcon == null) {
            Bitmap bitmap = BitmapFactory.decodeResource(
                    ContextUtils.getApplicationContext().getResources(), R.drawable.offline_pin);
            mDownloadSuccessLargeIcon = getLargeNotificationIcon(bitmap);
        }
        builder.setDeleteIntent(buildSummaryIconIntent(notificationId));
        builder.setLargeIcon(icon != null ? icon : mDownloadSuccessLargeIcon);
        updateNotification(notificationId, builder.build(), id, null);
        stopTrackingInProgressDownload(id, true);
        return notificationId;
    }

    /**
     * Add a download failed notification.
     * @param id       The {@link ContentId} of the download.
     * @param fileName Filename of the download.
     * @param icon     A {@link Bitmap} to be used as the large icon for display.
     */
    @VisibleForTesting
    public void notifyDownloadFailed(ContentId id, String fileName, Bitmap icon) {
        // If the download is not in history db, fileName could be empty. Get it from
        // SharedPreferences.
        if (TextUtils.isEmpty(fileName)) {
            DownloadSharedPreferenceEntry entry =
                    mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id);
            if (entry == null) return;
            fileName = entry.fileName;
        }

        int notificationId = getNotificationId(id);
        ChromeNotificationBuilder builder =
                buildNotification(android.R.drawable.stat_sys_download_done, fileName,
                        ContextUtils.getApplicationContext().getResources().getString(
                                R.string.download_notification_failed));
        if (icon != null) builder.setLargeIcon(icon);
        builder.setDeleteIntent(buildSummaryIconIntent(notificationId));
        updateNotification(notificationId, builder.build(), id, null);
        stopTrackingInProgressDownload(id, true);
    }

    /**
     * Helper method to build a PendingIntent from the provided intent.
     * @param intent Intent to broadcast.
     * @param notificationId ID of the notification.
     */
    private PendingIntent buildPendingIntent(Intent intent, int notificationId) {
        return PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId,
                intent, PendingIntent.FLAG_UPDATE_CURRENT);
    }

    private PendingIntent buildSummaryIconIntent(int notificationId) {
        Intent intent =
                new Intent(ContextUtils.getApplicationContext(), DownloadBroadcastReceiver.class);
        intent.setAction(ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON);
        return buildPendingIntent(intent, notificationId);
    }

    /**
     * Helper method to build an download action Intent from the provided information.
     * @param context {@link Context} to pull resources from.
     * @param action Download action to perform.
     * @param id The {@link ContentId} of the download.
     * @param isOffTheRecord Whether the download is incognito.
     */
    static Intent buildActionIntent(
            Context context, String action, ContentId id, boolean isOffTheRecord) {
        ComponentName component = new ComponentName(
                context.getPackageName(), DownloadBroadcastReceiver.class.getName());
        Intent intent = new Intent(action);
        intent.setComponent(component);
        intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_ID, id != null ? id.id : "");
        intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE, id != null ? id.namespace : "");
        intent.putExtra(EXTRA_IS_OFF_THE_RECORD, isOffTheRecord);
        return intent;
    }

    /**
     * Builds a notification to be displayed.
     * @param iconId Id of the notification icon.
     * @param title Title of the notification.
     * @param contentText Notification content text to be displayed.
     * @return notification builder that builds the notification to be displayed
     */
    private ChromeNotificationBuilder buildNotification(
            int iconId, String title, String contentText) {
        Bundle extras = new Bundle();
        extras.putInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID, iconId);

        ChromeNotificationBuilder builder =
                NotificationBuilderFactory
                        .createChromeNotificationBuilder(
                                true /* preferCompat */, ChannelDefinitions.ChannelId.DOWNLOADS)
                        .setContentTitle(
                                DownloadUtils.getAbbreviatedFileName(title, MAX_FILE_NAME_LENGTH))
                        .setSmallIcon(iconId)
                        .setLocalOnly(true)
                        .setAutoCancel(true)
                        .setContentText(contentText)
                        .setGroup(NotificationConstants.GROUP_DOWNLOADS)
                        .addExtras(extras);
        return builder;
    }

    private Bitmap getLargeNotificationIcon(Bitmap bitmap) {
        Resources resources = ContextUtils.getApplicationContext().getResources();
        int height = (int) resources.getDimension(android.R.dimen.notification_large_icon_height);
        int width = (int) resources.getDimension(android.R.dimen.notification_large_icon_width);
        final OvalShape circle = new OvalShape();
        circle.resize(width, height);
        final Paint paint = new Paint();
        paint.setColor(ApiCompatibilityUtils.getColor(resources, R.color.google_blue_grey_500));

        final Bitmap result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(result);
        circle.draw(canvas, paint);
        float leftOffset = (width - bitmap.getWidth()) / 2f;
        float topOffset = (height - bitmap.getHeight()) / 2f;
        if (leftOffset >= 0 && topOffset >= 0) {
            canvas.drawBitmap(bitmap, leftOffset, topOffset, null);
        } else {
            // Scale down the icon into the notification icon dimensions
            canvas.drawBitmap(bitmap,
                    new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()),
                    new Rect(0, 0, width, height),
                    null);
        }
        return result;
    }

    /**
     * Retrieves DownloadSharedPreferenceEntry from a download action intent.
     * @param intent Intent that contains the download action.
     */
    private DownloadSharedPreferenceEntry getDownloadEntryFromIntent(Intent intent) {
        if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) return null;
        return mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(
                getContentIdFromIntent(intent));
    }

    /**
     * Helper method to launch the browser process and handle a download operation that is included
     * in the given intent.
     * @param intent Intent with the download operation.
     */
    private void handleDownloadOperation(final Intent intent) {
        // Process updating the summary notification first.  This has no impact on a specific
        // download.
        if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) {
            updateSummaryIcon(ContextUtils.getApplicationContext(), mNotificationManager, -1, null);
            hideSummaryNotificationIfNecessary(-1);
            return;
        }

        // TODO(qinmin): Figure out how to properly handle this case.
        final ContentId id = getContentIdFromIntent(intent);
        final DownloadSharedPreferenceEntry entry = getDownloadEntryFromIntent(intent);
        if (entry == null
                && !(id != null && LegacyHelpers.isLegacyOfflinePage(id)
                           && TextUtils.equals(intent.getAction(), ACTION_DOWNLOAD_OPEN))
                && !(TextUtils.equals(intent.getAction(), ACTION_NOTIFICATION_CLICKED))
                && !(TextUtils.equals(intent.getAction(), ACTION_DOWNLOAD_RESUME_ALL))) {
            handleDownloadOperationForMissingNotification(intent);
            hideSummaryNotificationIfNecessary(-1);
            return;
        }

        if (ACTION_DOWNLOAD_PAUSE.equals(intent.getAction())) {
            // If browser process already goes away, the download should have already paused. Do
            // nothing in that case.
            if (!DownloadManagerService.hasDownloadManagerService()) {
                // TODO(dtrainor): Should we spin up native to make sure we have the icon?  Or maybe
                // build a Java cache for easy access.
                notifyDownloadPaused(entry.id, entry.fileName, !entry.isOffTheRecord, false,
                        entry.isOffTheRecord, entry.isTransient, null);
                hideSummaryNotificationIfNecessary(-1);
                return;
            }
        } else if (ACTION_DOWNLOAD_RESUME.equals(intent.getAction())) {
            // If user manually resumes a download, update the network type if it
            // is not metered previously.
            boolean canDownloadWhileMetered = entry.canDownloadWhileMetered
                    || DownloadManagerService.isActiveNetworkMetered(
                               ContextUtils.getApplicationContext());
            // Update the SharedPreference entry.
            mDownloadSharedPreferenceHelper.addOrReplaceSharedPreferenceEntry(
                    new DownloadSharedPreferenceEntry(entry.id, entry.notificationId,
                            entry.isOffTheRecord, canDownloadWhileMetered, entry.fileName, true,
                            entry.isTransient));
        } else if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())
                && (mDownloadSharedPreferenceHelper.getEntries().isEmpty()
                           || DownloadManagerService.hasDownloadManagerService())) {
            hideSummaryNotificationIfNecessary(-1);
            return;
        } else if (ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) {
            // TODO(fgorski): Do we even need to do anything special here, before we launch Chrome?
        } else if (ACTION_DOWNLOAD_CANCEL.equals(intent.getAction())
                && IntentUtils.safeGetBooleanExtra(intent, EXTRA_NOTIFICATION_DISMISSED, false)) {
            // User canceled a download by dismissing its notification from earlier versions, ignore
            // it. TODO(qinmin): remove this else-if block after M60.
            return;
        }

        BrowserParts parts = new EmptyBrowserParts() {
            @Override
            public void finishNativeInitialization() {
                // Make sure the OfflineContentAggregator bridge is initialized.
                OfflineContentAggregatorNotificationBridgeUiFactory.instance();

                DownloadServiceDelegate downloadServiceDelegate =
                        isDownloadOpenOrNotificationClickedAction(intent) ? null
                                                                          : getServiceDelegate(id);
                if (ACTION_DOWNLOAD_CANCEL.equals(intent.getAction())) {
                    // TODO(qinmin): Alternatively, we can delete the downloaded content on
                    // SD card, and remove the download ID from the SharedPreferences so we
                    // don't need to restart the browser process. http://crbug.com/579643.
                    cancelNotification(entry.notificationId, entry.id);
                    downloadServiceDelegate.cancelDownload(entry.id, entry.isOffTheRecord);
                    for (Observer observer : mObservers) {
                        observer.onDownloadCanceled(entry.id);
                    }
                } else if (ACTION_DOWNLOAD_PAUSE.equals(intent.getAction())) {
                    // TODO(dtrainor): Consider hitting the delegate and rely on that to update the
                    // state.
                    notifyDownloadPaused(entry.id, entry.fileName, true, false,
                            entry.isOffTheRecord, entry.isTransient, null);
                    downloadServiceDelegate.pauseDownload(entry.id, entry.isOffTheRecord);
                } else if (ACTION_DOWNLOAD_RESUME.equals(intent.getAction())) {
                    // TODO(dtrainor): Consider hitting the delegate and rely on that to update the
                    // state.
                    notifyDownloadPending(entry.id, entry.fileName, entry.isOffTheRecord,
                            entry.canDownloadWhileMetered, entry.isTransient, null);
                    downloadServiceDelegate.resumeDownload(
                            entry.id, entry.buildDownloadItem(), true);
                } else if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) {
                    assert entry == null;
                    resumeAllPendingDownloads();
                } else if (ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) {
                    ContentId id = getContentIdFromIntent(intent);
                    if (id != null) {
                        OfflineContentAggregatorNotificationBridgeUiFactory.instance().openItem(id);
                    }
                } else if (ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) {
                    openDownload(ContextUtils.getApplicationContext(), intent);
                } else {
                    Log.e(TAG, "Unrecognized intent action.", intent);
                }
                if (!isDownloadOpenOrNotificationClickedAction(intent)) {
                    downloadServiceDelegate.destroyServiceDelegate();
                }

                hideSummaryNotificationIfNecessary(ACTION_DOWNLOAD_CANCEL.equals(intent.getAction())
                                ? entry.notificationId
                                : -1);
            }
        };
        try {
            ChromeBrowserInitializer.getInstance(ContextUtils.getApplicationContext())
                    .handlePreNativeStartup(parts);
            ChromeBrowserInitializer.getInstance(ContextUtils.getApplicationContext())
                    .handlePostNativeStartup(true, parts);
        } catch (ProcessInitException e) {
            Log.e(TAG, "Unable to load native library.", e);
            ChromeApplication.reportStartupErrorAndExit(e);
        }
    }

    private boolean isDownloadOpenOrNotificationClickedAction(Intent intent) {
        return ACTION_DOWNLOAD_OPEN.equals(intent.getAction())
                || ACTION_NOTIFICATION_CLICKED.equals(intent.getAction());
    }

    /**
     * Called to open a particular download item.  Falls back to opening Download Home.
     * @param context Context of the receiver.
     * @param intent Intent from the android DownloadManager.
     */
    private void openDownload(final Context context, Intent intent) {
        long ids[] =
                intent.getLongArrayExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS);
        if (ids == null || ids.length == 0) {
            DownloadManagerService.openDownloadsPage(context);
            return;
        }

        long id = ids[0];
        Uri uri = DownloadManagerDelegate.getContentUriFromDownloadManager(context, id);
        if (uri == null) {
            DownloadManagerService.openDownloadsPage(context);
            return;
        }

        String downloadFilename = IntentUtils.safeGetStringExtra(
                intent, DownloadNotificationService.EXTRA_DOWNLOAD_FILE_PATH);
        boolean isSupportedMimeType = IntentUtils.safeGetBooleanExtra(
                intent, DownloadNotificationService.EXTRA_IS_SUPPORTED_MIME_TYPE, false);
        boolean isOffTheRecord = IntentUtils.safeGetBooleanExtra(
                intent, DownloadNotificationService.EXTRA_IS_OFF_THE_RECORD, false);
        String originalUrl = IntentUtils.safeGetStringExtra(intent, Intent.EXTRA_ORIGINATING_URI);
        String referrer = IntentUtils.safeGetStringExtra(intent, Intent.EXTRA_REFERRER);
        ContentId contentId = DownloadNotificationService.getContentIdFromIntent(intent);
        DownloadManagerService.openDownloadedContent(context, downloadFilename, isSupportedMimeType,
                isOffTheRecord, contentId.id, id, originalUrl, referrer,
                DownloadMetrics.DownloadOpenSource.NOTIFICATION);
    }

    /**
     * Handles operations for downloads that the DownloadNotificationService is unaware of.
     *
     * This can happen because the DownloadNotificationService learn about downloads later than
     * Download Home does, and may not yet have a DownloadSharedPreferenceEntry for the item.
     *
     * TODO(qinmin): Figure out how to fix the SharedPreferences so that it properly tracks entries.
     */
    private void handleDownloadOperationForMissingNotification(Intent intent) {
        // This function should only be called via Download Home, but catch this case to be safe.
        if (!DownloadManagerService.hasDownloadManagerService()) return;

        String action = intent.getAction();
        ContentId id = getContentIdFromIntent(intent);
        boolean isOffTheRecord =
                IntentUtils.safeGetBooleanExtra(intent, EXTRA_IS_OFF_THE_RECORD, false);
        if (!LegacyHelpers.isLegacyDownload(id)) return;

        // Pass information directly to the DownloadManagerService.
        if (TextUtils.equals(action, ACTION_DOWNLOAD_CANCEL)) {
            getServiceDelegate(id).cancelDownload(id, isOffTheRecord);
        } else if (TextUtils.equals(action, ACTION_DOWNLOAD_PAUSE)) {
            getServiceDelegate(id).pauseDownload(id, isOffTheRecord);
        } else if (TextUtils.equals(action, ACTION_DOWNLOAD_RESUME)) {
            DownloadInfo info = new DownloadInfo.Builder()
                                        .setDownloadGuid(id.id)
                                        .setIsOffTheRecord(isOffTheRecord)
                                        .build();
            getServiceDelegate(id).resumeDownload(id, new DownloadItem(false, info), true);
        }
    }

    /**
     * Gets appropriate download delegate that can handle interactions with download item referred
     * to by the entry.
     * @param id The {@link ContentId} to grab the delegate for.
     * @return delegate for interactions with the entry
     */
    DownloadServiceDelegate getServiceDelegate(ContentId id) {
        return LegacyHelpers.isLegacyDownload(id)
                ? DownloadManagerService.getDownloadManagerService()
                : OfflineContentAggregatorNotificationBridgeUiFactory.instance();
    }

    @VisibleForTesting
    void updateNotification(int id, Notification notification) {
        // Disabling StrictMode to avoid violations (crbug.com/809864).
        try (StrictModeContext unused = StrictModeContext.allowDiskReads()) {
            mNotificationManager.notify(NOTIFICATION_NAMESPACE, id, notification);
        }
    }

    private void updateNotification(int notificationId, Notification notification, ContentId id,
            DownloadSharedPreferenceEntry entry) {
        updateNotification(notificationId, notification);
        trackNotificationUma(id, notification);

        if (entry != null) {
            mDownloadSharedPreferenceHelper.addOrReplaceSharedPreferenceEntry(entry);
        } else {
            mDownloadSharedPreferenceHelper.removeSharedPreferenceEntry(id);
        }
        updateSummaryIcon(ContextUtils.getApplicationContext(), mNotificationManager, -1,
                new Pair<Integer, Notification>(notificationId, notification));
    }

    private void trackNotificationUma(ContentId id, Notification notification) {
        // Check if we already have an entry in the DownloadSharedPreferenceHelper.  This is a
        // reasonable indicator for whether or not a notification is already showing (or at least if
        // we had built one for this download before.
        if (mDownloadSharedPreferenceHelper.hasEntry(id)) return;
        NotificationUmaTracker.getInstance().onNotificationShown(
                LegacyHelpers.isLegacyOfflinePage(id)
                        ? NotificationUmaTracker.SystemNotificationType.DOWNLOAD_PAGES
                        : NotificationUmaTracker.SystemNotificationType.DOWNLOAD_FILES,
                notification);

        // Record number of other notifications when there's a new notification.
        DownloadNotificationUmaHelper.recordExistingNotificationsCountHistogram(
                mDownloadSharedPreferenceHelper.getEntries().size(), false /* withForeground */);
    }

    /**
     * Checks if an intent requires operations on a download.
     * @param intent An intent to validate.
     * @return true if the intent requires actions, or false otherwise.
     */
    static boolean isDownloadOperationIntent(Intent intent) {
        if (intent == null) return false;
        if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) return true;
        if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) return true;
        if (!ACTION_DOWNLOAD_CANCEL.equals(intent.getAction())
                && !ACTION_DOWNLOAD_RESUME.equals(intent.getAction())
                && !ACTION_DOWNLOAD_PAUSE.equals(intent.getAction())
                && !ACTION_DOWNLOAD_OPEN.equals(intent.getAction())
                && !ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) {
            return false;
        }

        ContentId id = getContentIdFromIntent(intent);
        if (id == null) return false;
        return true;
    }

    private static boolean canResumeDownload(Context context, DownloadSharedPreferenceEntry entry) {
        if (entry == null) return false;
        if (!entry.isAutoResumable) return false;

        boolean isNetworkMetered = DownloadManagerService.isActiveNetworkMetered(context);
        return entry.canDownloadWhileMetered || !isNetworkMetered;
    }

    /**
     * @param intent The {@link Intent} to pull from and build a {@link ContentId}.
     * @return A {@link ContentId} built by pulling extras from {@code intent}.  This will be
     *         {@code null} if {@code intent} is missing any required extras.
     */
    public static ContentId getContentIdFromIntent(Intent intent) {
        if (!intent.hasExtra(EXTRA_DOWNLOAD_CONTENTID_ID)
                || !intent.hasExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE)) {
            return null;
        }

        return new ContentId(
                IntentUtils.safeGetStringExtra(intent, EXTRA_DOWNLOAD_CONTENTID_NAMESPACE),
                IntentUtils.safeGetStringExtra(intent, EXTRA_DOWNLOAD_CONTENTID_ID));
    }

    /**
     * Resumes all pending downloads from SharedPreferences. If a download is
     * already in progress, do nothing.
     */
    public void resumeAllPendingDownloads() {
        if (!DownloadManagerService.hasDownloadManagerService()) return;
        List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries();
        for (int i = 0; i < entries.size(); ++i) {
            DownloadSharedPreferenceEntry entry = entries.get(i);
            if (!canResumeDownload(ContextUtils.getApplicationContext(), entry)) continue;
            if (mDownloadsInProgress.contains(entry.id)) continue;

            notifyDownloadPending(entry.id, entry.fileName, entry.isOffTheRecord,
                    entry.canDownloadWhileMetered, entry.isTransient, null);
            DownloadServiceDelegate downloadServiceDelegate = getServiceDelegate(entry.id);
            downloadServiceDelegate.resumeDownload(entry.id, entry.buildDownloadItem(), false);
            downloadServiceDelegate.destroyServiceDelegate();
        }
    }

    /**
     * Return the notification ID for the given download {@link ContentId}.
     * @param id the {@link ContentId} of the download.
     * @return notification ID to be used.
     */
    private int getNotificationId(ContentId id) {
        DownloadSharedPreferenceEntry entry =
                mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id);
        if (entry != null) return entry.notificationId;
        int notificationId = mNextNotificationId;
        mNextNotificationId = mNextNotificationId == Integer.MAX_VALUE
                ? STARTING_NOTIFICATION_ID : mNextNotificationId + 1;
        SharedPreferences.Editor editor = mSharedPrefs.edit();
        editor.putInt(KEY_NEXT_DOWNLOAD_NOTIFICATION_ID, mNextNotificationId);
        editor.apply();
        return notificationId;
    }
}
